Memoize slot-cache chain reachability for closure reads - #2726
Merged
Conversation
Closure-variable reads at hops 1-3 re-validated their slot location on every access: CanReachAtOuterHop re-probed every intermediate declarative environment (linear slot-name scan + dictionary probe per hop) on each read, ~6% of dromaeo-string-base64-modern (3.5% CanReachAtOuterHop + 2.4% TryResolveNonLocal). A successful walk is now published as an immutable SlotChainMemo pinning the probed chain links by identity plus Engine._envBindingInjectionEpoch, mirroring the global read cache's NestedChainMemo. Steady-state closure reads then validate with a handful of reference compares instead of re-walking. Soundness: a declarative environment's name set is a deterministic function of its defining AST node/definition (every reuse channel re-initializes an instance with the identical name set), the only mutations that grow a pre-existing environment's name set (sloppy direct eval hoisting, AnnexB block-function var copies) bump the injection epoch, deletions only shrink it, and an environment's class is immutable - so pinned "intermediate does not own the name" probes stay false while identity and epoch hold. Validation follows the CURRENT _outerEnv pointers against the pinned links, so pooled environments re-attached under different outers and with-ObjectEnvironments (which break link identity by construction) fall through to the authoritative walk, which also stays in charge of every miss and re-publishes on success. ResolveAndPopulate publishes the memo directly when it lands at hops 1-3 since its traversal proves the same condition. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
lahma
enabled auto-merge (squash)
July 21, 2026 17:00
lahma
added a commit
that referenced
this pull request
Jul 21, 2026
Single-session re-measure of all lanes on main with #2725/#2726 merged. Extends the campaign notes with the round-2 wins (JSON scan fast paths, closure-read chain memoization) and records the measured-and-dropped direct-dispatch experiment: the builtin-call path is already at its cost floor, so the residual string-row gap is interpreter dispatch itself, not call ceremony. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This was referenced Jul 22, 2026
legrab
added a commit
to legrab/pocok
that referenced
this pull request
Jul 29, 2026
Updated [Jint](https://github.com/sebastienros/jint) from 4.13.0 to 4.14.0. <details> <summary>Release notes</summary> _Sourced from [Jint's releases](https://github.com/sebastienros/jint/releases)._ ## 4.14.0 Jint 4.14.0 is an **interop-focused performance release**: CLR arrays now cross into script as live views instead of copies, recently wrapped host objects reuse their wrappers, single-candidate interop method calls dispatch through compiled invokers, and `JSON.parse` interns repeated keys and values. Host collection traversal is **10.9× faster** than 4.13.0. **Two interop defaults changed in this release** — read the first two highlights if you pass CLR arrays to scripts or rely on per-crossing conversion behavior; everything else needs no code changes to benefit. ### Highlights **CLR arrays are live views by default (behavior change).** `Options.Interop.ArrayConversion` now defaults to `ArrayConversionMode.LiveView` (#2721, #2728, #2735): a single-rank `T[]` crossing into script becomes a live, fixed-size view over the underlying array — the way wrapped `List<T>` already behaves — instead of being copied into a new JS array on every read. Writes go through in both directions, and arrays exposed through read-only-declared members (e.g. `IReadOnlyList<T>`) produce read-only views. Iteration, `Array.prototype` methods, JSON serialization, index-key enumeration (`Object.keys` / `for..in` yield `"0"`..`"n-1"`) and `undefined` for out-of-range reads all behave array-like, but `Array.isArray` returns `false`, and because CLR arrays are fixed-size, resizing operations (`push`/`pop`/`length` writes) throw a `TypeError` like integer-indexed exotic objects do — `shift`/`splice` may move elements before their length change throws, as for typed arrays. Set `Options.Interop.ArrayConversion = ArrayConversionMode.Copy` to restore the 4.13 behavior. **Recently wrapped CLR objects reuse their wrappers (behavior change).** The new `Options.Interop.CacheRecentObjectWrappers` defaults to `true` (#2734): a small bounded ring (8 entries, keyed by reference identity and exposed type) reuses wrappers for host objects that repeatedly cross into script. Wrapper identity becomes stable (`host.Obj === host.Obj`), script-attached state (freeze, `defineProperty`, expandos) survives crossings, and the per-crossing wrapper allocation disappears. Under `Copy` array conversion this also means repeated reads of the same CLR array reuse the first `JsArray` snapshot while it stays cached — CLR-side mutations are not re-copied; set the option to `false` for the pre-4.14 fresh-snapshot-per-crossing behavior. `Engine.Dispose()` releases the ring. **Interop fast lanes.** Single-candidate method calls run through a compiled invoker that binds and invokes without argument arrays or boxing (#2733), with per-parameter binding flags precomputed (#2719). Resolved `ObjectWrapper` members get a per-call-site inline cache (#2722) and the member-call fast path covers primitive string receivers (#2717). Array-like wrapper creation is a cached factory call with lazily materialized `length` (#2730), primitive elements convert without boxing on both indexed reads and `Array.prototype` iteration (#2731, #2735), the wrapper identity caches cover CLR arrays (#2716), and implicitly implemented interface methods are deduplicated in member resolution (#2711). **JSON.** `JSON.parse` interns property keys and string values within a parse, parses numbers off the span with an exactly-rounded fast path and scans string content in bulk (#2718, #2725, #2732) — the `json-parse-modern` comparison row is 6% faster with 23% less allocation than 4.13.0. Parsing is also aligned with the JSON grammar (#2738): malformed numbers like `-09` and `1.` are now rejected as in V8, while raw U+2028/U+2029 in strings and escaped control characters in keys — both valid JSON — are now accepted. **Strings.** Chained `slice`/`substring` and `split` segments stay zero-copy views (#2720), whole-string `substring`/`substr` return the receiver, and mismatched-length comparisons no longer materialize views (#2740). **Execution constraints at host boundaries.** Timeouts and cancellation are re-checked when control returns from host CLR code, so detection latency is bounded by one host call instead of a statement-count window, without adding per-statement cost — gated on execution depth so host-side reads of wrapped objects on an idle engine never observe a stale timer (#2713, #2714, #2715). Execution-context depth stays balanced when constraint exceptions unwind generator/async frames, and a host callback that re-enters the engine no longer resets the outer script's budget (#2736). **Correctness (including a pre-release review).** A review of everything since 4.13.0 fixed: spurious TDZ when a for-header reads a name the loop body shadows (#2709) and stale closure captures from destructuring defaults in for-loop headers (#2739); the compiled-invoker lane now defers to custom `ITypeConverter`s and preserves reflection exception types (#2737); and the new wrapper defaults were hardened — declared-type contracts for arrays (an `IReadOnlyList<T>`-typed member no longer yields a writable view), a static type-mapper poisoning crash, `Engine.Dispose` releasing the wrapper caches, and JS-array `in`/enumeration/out-of-range semantics on array views (#2735). Closure reads memoize slot-cache chain reachability (#2726). On the [engine comparison benchmarks](https://github.com/sebastienros/jint/blob/main/Jint.Benchmark/README.md), Jint 4.14.0 beats ClearScript (native V8) by 7.1×–9.1× on every script ↔ host interop row — host collection traversal went from last to second among all engines at 15,597 → 1,433 µs with 99% less allocation — while remaining the fastest managed engine on 10 of 12 pure-JS scripts and the fastest interpreter on all 12, and now leading `array-stress` and `dromaeo-object-array`, rows V8 narrowly led at 4.13.0. ## What's Changed * Refresh EngineComparison benchmarks for 4.13.0 by @lahma in sebastienros/jint#2704 * Fix spurious TDZ when a for-header reads a name the loop body shadows by @svenrog in sebastienros/jint#2709 * Bump the testing group with 1 update by @dependabot[bot] in sebastienros/jint#2710 * Add tests for using modules from script code run via Evaluate by @lahma in sebastienros/jint#2712 * Deduplicate implicitly implemented interface methods in member resolution by @lahma in sebastienros/jint#2711 * Re-check amortized constraints at interpreter/host-code boundaries by @lahma in sebastienros/jint#2713 * Add ClearScript V8 to engine comparison benchmarks, trim suite, add script-to-host interop suite by @viceice in sebastienros/jint#1775 * Gate host-boundary constraint checks on active evaluation and harden coverage by @lahma in sebastienros/jint#2714 * Key the host-boundary constraint gate on execution depth and close remaining lanes by @lahma in sebastienros/jint#2715 * Cover CLR arrays with the interop identity caches by @lahma in sebastienros/jint#2716 * Extend the member-call fast path to primitive string receivers by @lahma in sebastienros/jint#2717 * Intern object property keys within a single JSON parse by @lahma in sebastienros/jint#2718 * Precompute per-parameter interop binding flags by @lahma in sebastienros/jint#2719 * Keep slice-of-slice and split segments zero-copy by @lahma in sebastienros/jint#2720 * Add opt-in ClrArrayConversion.LiveView interop mode for CLR arrays by @lahma in sebastienros/jint#2721 * Cache resolved ObjectWrapper members per member-expression node by @lahma in sebastienros/jint#2722 * Refresh engine comparison README after the V8-gap campaign by @lahma in sebastienros/jint#2723 * Bulk string scanning and a simple-number fast path for JSON.parse by @lahma in sebastienros/jint#2725 * Memoize slot-cache chain reachability for closure reads by @lahma in sebastienros/jint#2726 * Refresh engine comparison tables after the second campaign round by @lahma in sebastienros/jint#2727 * Default Interop.ArrayConversion to LiveView for 4.14 by @lahma in sebastienros/jint#2728 * Cache array-like wrapper factories and materialize length lazily by @lahma in sebastienros/jint#2730 * Convert primitive array-like wrapper elements without boxing by @lahma in sebastienros/jint#2731 * Add a compiled-invoker fast lane for single-candidate interop method calls by @lahma in sebastienros/jint#2733 * Intern JSON.parse string values and parse numbers off the span by @lahma in sebastienros/jint#2732 * Default Interop.CacheRecentObjectWrappers to true for 4.14 by @lahma in sebastienros/jint#2734 * Harden LiveView array wrappers and interop wrapper caches for 4.14 by @lahma in sebastienros/jint#2735 * Keep execution-context depth balanced under raw constraint exceptions by @lahma in sebastienros/jint#2736 ... (truncated) Commits viewable in [compare view](sebastienros/jint@v4.13.0...v4.14.0). </details> [](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) --- <details> <summary>Dependabot commands and options</summary> <br /> You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show <dependency name> ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) </details>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Campaign-2 item (follow-up to #2716–#2723). Profiles show closure-variable reads re-validating their slot location on every access —
SlotLocationCache.CanReachAtOuterHop3.5% +TryResolveNonLocal2.4% ondromaeo-string-base64-modern, driven fromJintIdentifierExpression.GetValue(closure-captured lookup tables read in tight loops).Change
Plain per-node
(lastEnv, hop, slot)caching guarded by environment identity alone is unsound — environment shapes are not immutable (sloppy directevalinjects vars into pre-existing function environments; AnnexB block-function semantics create var-scope bindings mid-execution). Instead this extends the guard pattern the codebase already ships for exactly this hazard in the global-read path (NestedChainMemo+Engine._envBindingInjectionEpoch):SlotChainMemopins the probed intermediate chain links by identity plus the injection epoch after a successful hop-1..3 walk. Steady-state closure reads validate with ~5 reference compares + 1 int compare instead of per-hopHasBindingprobes.withinsertion breaks link identity by construction; pooled/reused environments re-initialize with per-definition-deterministic name sets (every reuse channel audited); all dynamic injections bump the epoch (everyCreateMutableBindingcaller audited); deletions only shrink name sets, so a cached "not here" verdict stays valid.Numbers (launchCount-5, both sides, default job otherwise)
Gates
Jint.Tests 3706/3643 (net10/net472) including 6 new soundness tests (alternating closure instances at one callsite, sloppy direct-eval shadowing mid-loop,
witharound closure reads with mid-loop injection, deep nesting past the memo depth, pooled loop environments re-attached across entries); PublicInterface 114/114; Test262 99,431 / 0 failed; CommonScripts 28/28.🤖 Generated with Claude Code